using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;
using Sedna.Domain.Enum;

namespace Sedna.Domain
{
    /// <summary>
    /// Read-only value object class that is served to represent time in the domain model to milliseconds.
    /// 
    /// To map this class to the DB with NHibernate the <see cref="Sedna.Core.Persistence.TimeType"/> is used
    /// </summary>
    [Serializable]
    public class Time : IComparable
    {
        public static readonly int MaxMilliseconds = 86399999;
        public static readonly int MaxSeconds = 86399;
        public static readonly int MaxMinutes = 1439;
        public static readonly int MaxHours = 23;

        public static TimeScale Scale = TimeScale.Hour12;
        
        private TimeSpan time;
        private DateTimePrecision defaultPrecision = DateTimePrecision.ToMillisecond;

        # region Constructors
        
        /// <summary>
        /// Creates object using current time
        /// </summary>
        public Time()
            : this(DateTime.Now)
        {
        }

        /// <summary>
        /// Creates object using time from DateTime object
        /// </summary>
        public Time(DateTime dateTime)
        {
            time = new TimeSpan(0, dateTime.Hour, dateTime.Minute, dateTime.Second, dateTime.Millisecond);
        }

        /// <summary>
        /// Creates time object
        /// </summary>
        /// <param name="timeSpan">Total milliseconds must be from 0 to <see cref="MaxMilliseconds"/></param>
        public Time(TimeSpan timeSpan)
        {
            if (timeSpan.TotalMilliseconds < 0 || timeSpan.TotalMilliseconds > MaxMilliseconds)
                throw new ArgumentOutOfRangeException(string.Format("Milliseconds must be from 0 to {0}", MaxMilliseconds));

            time = new TimeSpan(timeSpan.Ticks);
        }
        
        /// <summary>
        /// Creates time object
        /// </summary>
        /// <param name="hours">0-23</param>
        /// <param name="minutes">0-59</param>
        /// <param name="seconds">0-59</param>
        /// <param name="milliseconds">0-999</param>
        public Time(int hours, int minutes, int seconds, int milliseconds)
        {
            if (hours < 0 || hours > 23)
                throw new ArgumentOutOfRangeException(string.Format("Hours must be from 0 to 23"));

            if (minutes < 0 || minutes > 59)
                throw new ArgumentOutOfRangeException(string.Format("Minutes must be from 0 to 59"));

            if (seconds < 0 || seconds > 59)
                throw new ArgumentOutOfRangeException(string.Format("Seconds must be from 0 to 59"));

            if (milliseconds < 0 || milliseconds > 999)
                throw new ArgumentOutOfRangeException(string.Format("Milliseconds must be from 0 to 999"));
            
            time = new TimeSpan(0, hours, minutes, seconds, milliseconds);
        }

        /// <summary>
        /// Creates time object
        /// </summary>
        /// <param name="hours">0-23</param>
        /// <param name="minutes">0-59</param>
        /// <param name="seconds">0-59</param>
        public Time(int hours, int minutes, int seconds)
            : this(hours, minutes, seconds, 0)
        {            
        }


        /// <summary>
        /// Creates time object
        /// </summary>
        /// <param name="hours">0-23</param>
        /// <param name="minutes">0-59</param>
        public Time(int hours, int minutes)
            : this(hours, minutes, 0, 0)
        {
        }

        /// <summary>
        /// Creates time object
        /// </summary>
        /// <param name="hours">0-23</param>
        public Time(int hours)
            : this(hours, 0, 0, 0)
        {
        }        
        
        # endregion

        # region Factory Methods
        
        /// <summary>
        /// Creates time object
        /// </summary>
        /// <param name="milliseconds">must be from 0 to <see cref="MaxMilliseconds"/></param>
        public static Time CreateFromMilliseconds(int milliseconds)
        {
            if (milliseconds < 0 || milliseconds > MaxMilliseconds)
                throw new ArgumentOutOfRangeException(string.Format("Milliseconds must be from 0 to {0}", MaxMilliseconds));

            return new Time(new TimeSpan(0, 0, 0, 0, milliseconds));
        }

        /// <summary>
        /// Creates time object
        /// </summary>
        /// <param name="seconds">must be from 0 to <see cref="MaxSeconds"/></param>
        public static Time CreateFromSeconds(int seconds)
        {
            if (seconds < 0 || seconds > MaxSeconds)
                throw new ArgumentOutOfRangeException(string.Format("Seconds must be from 0 to {0}", MaxSeconds));

            return new Time(new TimeSpan(0, 0, 0, seconds, 0));
        }

        /// <summary>
        /// Creates time object
        /// </summary>
        /// <param name="minutes">must be from 0 to <see cref="MaxMinutes"/></param>
        public static Time CreateFromMinutes(int minutes)
        {
            if (minutes < 0 || minutes > MaxMinutes)
                throw new ArgumentOutOfRangeException(string.Format("Minutes must be from 0 to {0}", MaxMinutes));

            return new Time(new TimeSpan(0, 0, minutes, 0, 0));
        }

        /// <summary>
        /// Creates time object
        /// </summary>
        /// <param name="hours">must be from 0 to <see cref="MaxHours"/></param>
        public static Time CreateFromHours(int hours)
        {
            if (hours < 0 || hours > MaxHours)
                throw new ArgumentOutOfRangeException(string.Format("Hours must be from 0 to {0}", MaxHours));

            return new Time(new TimeSpan(0, hours, 0, 0, 0));
        }                
        
        # endregion

        # region Predefined Time
        
        /// <summary>
        /// Returns 00:00:00 000
        /// </summary>
        public static Time Midnight
        {
            get { return new Time(0); }
        }

        /// <summary>
        /// Returns 12:00:00 000
        /// </summary>
        public static Time Midday
        {
            get { return new Time(12); }
        }

        /// <summary>
        /// Returns 23:59:59 999
        /// </summary>
        public static Time EndDay
        {
            get { return new Time(23, 59, 59, 999); }
        }
        
        # endregion

        # region Properties

        /// <summary>
        /// Returns hours in 24-hour time scale
        /// </summary>
        public int Hours
        {
            get { return time.Hours; }
        }
        
        /// <summary>
        /// Returns hours according to specified <see cref="Scale"/> 
        /// </summary>
        public int ScaledHours
        {
            get
            {
                return ScaleHours(Scale);
            }
        }

        public int Minutes
        {
            get { return time.Minutes; }
        }

        public int Seconds
        {
            get { return time.Seconds; }
        }

        public int Milliseconds
        {
            get { return time.Milliseconds; }
        }

        public int TotalHours
        {
            get { return time.Hours; }
        }

        public int TotalMinutes
        {
            get { return System.Convert.ToInt32(time.TotalMinutes); }
        }

        public int TotalSeconds
        {
            get { return System.Convert.ToInt32(time.TotalSeconds); }
        }

        public int TotalMilliseconds
        {
            get { return System.Convert.ToInt32(time.TotalMilliseconds); }
        }
        
        /// <summary>
        /// Is before the midday (a.m.)
        /// </summary>
        public bool IsAnteMeridiem
        {
            get { return (time.Hours >= 0 && time.Hours <= 11); }
        }

        /// <summary>
        /// Is after the midday (p.m.)
        /// </summary>
        public bool IsPostMeridiem
        {
            get { return !IsAnteMeridiem; }
        }
        
        # endregion  
        
        # region Add Methods
        
        /// <summary>
        /// Returns new time object if <b>value</b> is not zero.
        /// 
        /// <b>value</b> can be of any sign.
        /// </summary>
        public Time Add(int value, TimePart part)
        {
            if (value == 0)
                return this;
            
            switch (part)
            {
                case TimePart.Hour:
                    return CreateFromHours(Math.Abs(TotalHours + value) % (MaxHours + 1));
                    
                case TimePart.Minute:
                    return CreateFromMinutes(Math.Abs(TotalMinutes + value) % (MaxMinutes + 1));

                case TimePart.Second:
                    return CreateFromSeconds(Math.Abs(TotalSeconds + value) % (MaxSeconds + 1));

                case TimePart.Millisecond:
                    return CreateFromMilliseconds(Math.Abs(TotalMilliseconds + value) % (MaxMilliseconds + 1));
            }

            throw new ArgumentException(String.Format("Unknown time part: {0}", part));
        }

        /// <summary>
        /// Returns new time object if <b>value</b> is not zero.
        /// 
        /// <b>value</b> can be of any sign.
        /// </summary>
        public Time AddHours(int value)
        {
            return Add(value, TimePart.Hour);
        }

        /// <summary>
        /// Returns new time object if <b>value</b> is not zero.
        /// 
        /// <b>value</b> can be of any sign.
        /// </summary>
        public Time AddMinutes(int value)
        {
            return Add(value, TimePart.Minute);
        }

        /// <summary>
        /// Returns new time object if <b>value</b> is not zero.
        /// 
        /// <b>value</b> can be of any sign.
        /// </summary>
        public Time AddSeconds(int value)
        {
            return Add(value, TimePart.Second);
        }

        /// <summary>
        /// Returns new time object if <b>value</b> is not zero.
        /// 
        /// <b>value</b> can be of any sign.
        /// </summary>
        public Time AddMilliseconds(int value)
        {
            return Add(value, TimePart.Millisecond);
        }
        
        # endregion

        # region Parsing Methods

        /// <summary>
        /// Trying to parse string to time object.
        /// 
        /// Throws: ArgumentNullException
        /// </summary>
        public static bool TryParse(string value, out Time time)
        {               
            try
            {
                time = Parse(value);
                return true;
            }
            catch (FormatException fe)
            {
                time = null;
                return false;
            }            
        }
        
        /// <summary>
        /// Parse string to time object.
        /// 
        /// If a string cannot be parsed to time, FormatException throws.
        /// 
        /// Throws: ArgumentNullException, FormatException
        /// </summary>
        public static Time Parse(string value)
        {
            if (value == null)
                throw new ArgumentNullException("String to parse is null");

            Regex r = new Regex(@"^((A|a|P|p)\ *\.?\ *(M|m)\ *\.?\ *)?(([0-1][0-9]|[2][0-3]|[0-23]):([0-5][0-9]|[0-59])|24:00)((:([0-5][0-9]|[0-59]))(\ +\d?\d?\d)?)?(\ *(A|a|P|p)\ *\.?\ *(M|m)\ *\.\ *?)?$");
            Match m = r.Match(value);

            if (m.Groups[0].Value.Length != value.Length)
                throw new FormatException(String.Format("Failed to parse '{0}' to time", value));

            // Filling groups

            IList<String> gs = new List<String>(m.Groups.Count);

            for (int i = 0; i < m.Groups.Count; i++)
                gs[i] = m.Groups[i].Value;

            # region Example

            /*
             * 
            P.M. 06:23:27 126 A.m.
             
            gr 0: P.M. 06:23:27 126 A.m.
            gr 1: P.M. 
            gr 2: 06:23
            gr 3: 06 // hours
            gr 4: 23 // minutes
            gr 5: :27 126
            gr 6: :27
            gr 7: 27 // seconds
            gr 8:  126 // milliseconds
            gr 9:  A.m.
             * 
            */

            # endregion

            # region Checking that gr1 == gr9

            gs[1] = gs[1].Replace(".", "").Replace(" ", "").ToLower();
            gs[9] = gs[9].Replace(".", "").Replace(" ", "").ToLower();

            if (gs[1].Length != 0 && gs[9].Length != 0 && !gs[1].Equals(gs[9])) // day parts don't match
                throw new FormatException(String.Format("Day part collision was found while parsing '{0}' to time", value));


            String dayPart = (gs[1].Length != 0) ? gs[1] : gs[9]; // Day part can still be empty

            # endregion

            int hours = 0;

            int.TryParse(gs[3], out hours); // If gs[2] = "24:00" then gs[3] = "" and we will get hours = 0

            if (dayPart.Length > 0) // am/pm
            {                
                // Scaling hours
                switch (hours)
                {
                    case 0:
                        {
                            hours = 0;
                            break;
                        }
                    case 12:
                        {
                            if (dayPart.Equals("am"))
                                hours = 0;
                            
                            break;
                        }
                    default:
                        {
                            if (dayPart.Equals("am"))
                            {
                                if (hours > 12)
                                    throw new FormatException(String.Format("Day part collision found while parsing '{0}' to time", value));
                            }
                            else // pm
                            {
                                // Allow 13:?? pm and etc.
                                
                                if (hours < 12)
                                    hours += 12;
                            }                                                        
                            
                            break;
                        }
                }
            }

            int minutes = 0;

            int.TryParse(gs[4], out minutes); // If gs[2] = "24:00" then gs[4] = "" and we will get minutes = 0

            int seconds = 0;

            int.TryParse(gs[7], out seconds);

            int milliseconds = 0;

            int.TryParse(gs[8].Trim(), out milliseconds);

            return new Time(hours, minutes, seconds, milliseconds);            
        }

        # endregion

        # region Formating Methods
        
        /// <summary>
        /// Returns time in <b>hh:mm:ss mmm</b> format depending on specified scale and precision
        /// </summary>
        public String Format(DateTimePrecision precision, TimeScale scale)
        {
            String dayPart = "";

            if (scale == TimeScale.Hour12)
                dayPart = IsAnteMeridiem ? " a.m." : " p.m.";

            string minutes = ":" + Minutes.ToString().PadLeft(2, '0');
            string seconds = ":" + Seconds.ToString().PadLeft(2, '0');
            string milli = " " + Milliseconds.ToString().PadLeft(3, '0');
            
            switch (precision)
            {
                case DateTimePrecision.ToHour:
                    {
                        minutes = "";
                        seconds = "";
                        milli = "";
                        break;
                    }
                case DateTimePrecision.ToMinute:
                    {
                        seconds = "";
                        milli = "";
                        break;
                    }
                case DateTimePrecision.ToSecond:
                    {
                        milli = "";
                        break;
                    }
                case DateTimePrecision.ToMillisecond:
                    {
                        break;
                    }
                default:
                    {
                        throw new ArgumentException(
                            String.Format("This time precision '{0}' is not supported.", precision));
                    }
            }

            return string.Format("{0}{1}{2}{3}{4}", ScaledHours.ToString().PadLeft(2, '0'), minutes, seconds, milli, dayPart).Trim();
        }

        /// <summary>
        /// Returns time in <b>hh:mm:ss mmm</b> format depending on specified precision and according to specified <see cref="Scale"/> 
        /// </summary>
        public String Format(DateTimePrecision precision)
        {
            return Format(precision, Scale);
        }

        /// <summary>
        /// Returns time in <b>hh:mm:ss</b> format according to specified <see cref="Scale"/> 
        /// </summary>
        public String ToLongFormat()
        {
            return Format(DateTimePrecision.ToSecond);
        }

        /// <summary>
        /// Returns time in <b>hh:mm</b> format according to specified <see cref="Scale"/> 
        /// </summary>
        public String ToShortFormat()
        {
            return Format(DateTimePrecision.ToMinute);
        }

        # endregion                              
        
        # region Scaling Hours

        public int ScaleHours(TimeScale scale)
        {
            int hours = time.Hours;

            if (scale == TimeScale.Hour12)
            {
                switch (hours)
                {
                    case 0:
                        {
                            hours = 12; // 12:?? am - midnight
                            break;
                        }
                    case 12:
                        {
                            // 12:?? pm (not am!) - midday
                            break;
                        }
                    default:
                        {
                            hours %= 12;
                            break;
                        }
                }
            }

            return hours;
        }

        # endregion

        # region Compare Methods

        /// <summary>
        /// If specified time goes after
        /// </summary>
        public bool Before(Time time)
        {
            return CompareTo(time) < 0;
        }

        /// <summary>
        /// If specified time goes before
        /// </summary>
        public bool After(Time time)
        {
            return CompareTo(time) > 0;
        }
        
        public bool Equals(Time that, DateTimePrecision precision)
        {
            if (ReferenceEquals(this, that))
                return true;

            if (ReferenceEquals(that, null))
                return false;

            return CompareTo(that, precision) == 0;
        }

        public override bool Equals(object obj)
        {
            return Equals(obj as Time, defaultPrecision);
        }
        
        public int CompareTo(Time that, DateTimePrecision precision)
        {
            if (ReferenceEquals(that, null))
                throw new ArgumentNullException("Transfered object is null");
            
            switch (precision)
            {
                case DateTimePrecision.ToHour:
                    {
                        return CompareToHour(that);
                    }
                case DateTimePrecision.ToMinute:
                    {
                        return CompareToMinute(that);
                    }
                case DateTimePrecision.ToSecond:
                    {
                        return CompareToSecond(that);
                    }
                case DateTimePrecision.ToMillisecond:
                    {
                        return CompareToMillisecond(that);
                    }
            }

            throw new ArgumentException(
                            String.Format("This time precision '{0}' is not supported.", precision));
        }

        public int CompareTo(object obj)
        {
            if (ReferenceEquals(obj, null))
                throw new ArgumentNullException("Transfered object is null");
            
            Time that = obj as Time;

            if (ReferenceEquals(that, null))
                throw new ArgumentException(String.Format("Transfered object is not an instance of '{0}'", GetType()));

            return CompareTo(that, defaultPrecision);
        }
        
        public int CompareToHour(Time that)
        {
            if (ReferenceEquals(that, null))
                throw new ArgumentNullException("Transfered object is null");

            return Hours.CompareTo(that.Hours);
        }

        public int CompareToMinute(Time that)
        {
            if (ReferenceEquals(that, null))
                throw new ArgumentNullException("Transfered object is null");

            int toHour = CompareToHour(that);

            return (toHour == 0) ? Minutes.CompareTo(that.Minutes) : toHour;
        }

        public int CompareToSecond(Time that)
        {
            if (ReferenceEquals(that, null))
                throw new ArgumentNullException("Transfered object is null");

            int toMinute = CompareToMinute(that);

            return (toMinute == 0) ? Seconds.CompareTo(that.Seconds) : toMinute;
        }

        public int CompareToMillisecond(Time that)
        {
            if (ReferenceEquals(that, null))
                throw new ArgumentNullException("Transfered object is null");

            int toSecond = CompareToSecond(that);

            return (toSecond == 0) ? Milliseconds.CompareTo(that.Milliseconds) : toSecond;
        }

        # endregion

        # region Convert Methods
        
        public Time Convert(DateTimePrecision precision)
        {
            return Convert(this, precision);
        }
        
        public static Time Convert(Time t, DateTimePrecision precision)
        {
            if (ReferenceEquals(t, null))
                throw new ArgumentNullException("Transfered object is null");
            
            switch (precision)
            {
                case DateTimePrecision.ToHour:
                    {
                        return new Time(t.Hours);
                    }
                case DateTimePrecision.ToMinute:
                    {
                        return new Time(t.Hours, t.Minutes);
                    }
                case DateTimePrecision.ToSecond:
                    {
                        return new Time(t.Hours, t.Minutes, t.Seconds);
                    }
                case DateTimePrecision.ToMillisecond:
                    {
                        return t;
                    }
            }

            throw new ArgumentException(String.Format("This time precision '{0}' is not supported.", precision));
        }
        
        # endregion

        # region Overloaded Operators

        public static bool operator ==(Time t1, Time t2)
        {
            // If both are null, or both are same instance, return true.
            if (ReferenceEquals(t1, t2))
            {
                return true;
            }

            // If one is null, but not both, return false.
            if (((object)t1 == null) || ((object)t2 == null))
            {
                return false;
            }

            return t1.time.TotalMilliseconds == t2.time.TotalMilliseconds;
        }

        public static bool operator !=(Time t1, Time t2)
        {
            return !(t1 == t2);
        }

        public static bool operator <(Time t1, Time t2)
        {
            if ((object)t1 == null)
                throw new ArgumentNullException("Cannot compare on '<' null object t1");

            if ((object)t2 == null)
                throw new ArgumentNullException("Cannot compare on '<' null object t2");

            return t1.CompareTo(t2) < 0;
        }

        public static bool operator >(Time t1, Time t2)
        {
            if ((object)t1 == null)
                throw new ArgumentNullException("Cannot compare on '>' null object t1");

            if ((object)t2 == null)
                throw new ArgumentNullException("Cannot compare on '>' null object t2");

            return t1.CompareTo(t2) > 0;
        }

        public static bool operator <=(Time t1, Time t2)
        {
            if ((object)t1 == null)
                throw new ArgumentNullException("Cannot compare on '<=' null object t1");

            if ((object)t2 == null)
                throw new ArgumentNullException("Cannot compare on '<=' null object t2");

            return t1.CompareTo(t2) <= 0;
        }

        public static bool operator >=(Time t1, Time t2)
        {
            if ((object)t1 == null)
                throw new ArgumentNullException("Cannot compare on '>=' null object t1");

            if ((object)t2 == null)
                throw new ArgumentNullException("Cannot compare on '>=' null object t2");

            return t1.CompareTo(t2) >= 0;
        }

        public static Time operator +(Time t1, Time t2)
        {
            if ((object)t1 == null)
                throw new ArgumentNullException("Cannot perform operation '+' with null object t1");

            if ((object)t2 == null)
                throw new ArgumentNullException("Cannot perform operation '+' with  null object t2");

            return CreateFromMilliseconds((t1.TotalMilliseconds + t2.TotalMilliseconds) % (MaxMilliseconds + 1));
        }

        public static Time operator -(Time t1, Time t2)
        {
            if ((object)t1 == null)
                throw new ArgumentNullException("Cannot perform operation '-' with null object t1");

            if ((object)t2 == null)
                throw new ArgumentNullException("Cannot perform operation '-' with  null object t2");

            return CreateFromMilliseconds(Math.Abs(t1.TotalMilliseconds - t2.TotalMilliseconds));
        }

        # endregion         

        /// <summary>
        /// Returns time in <b>hh:mm</b> format according to specified <see cref="Scale"/> 
        /// </summary>
        public override string ToString()
        {
            return ToShortFormat();
        }

        public override int GetHashCode()
        {
            return time.GetHashCode();
        }
    }
}
